<?php

namespace Desktopd\MUA;


// SMTP client connection
// Currently usable as a SMTP *submission* client
// XXX: Should have more general features?
class SMTPClientConnection implements Debuggable {
    const STAGE_BEFORE_HELLO = 1;
    const STAGE_BEFORE_MAIL = 2;
    const STAGE_MAIL_TRANSACTION = 3;
    const STAGE_AFTER_DATA = self::STAGE_BEFORE_MAIL;
    
    
    protected $stage = self::STAGE_BEFORE_HELLO;
    protected $connection;
    
    protected $commandLines = '';
    protected $responseLines = '';
    
    // supported features
    protected $ESMTP = false;
    protected $STARTTLS = false;
    protected $login = false;
    protected $plain = false;
    protected $digestMD5 = false;
    protected $cramMD5 = false;
    protected $dsn = false;
    
    protected $maxSize = 100000000; // minimum 64kB
    
    protected $debugHandlers = array();
    
    
    public function __construct (TCPClientConnection $connection) {
        $this->connection = $connection;
        if (!$connection->isNew()) {
            // Something already happened
            throw new \RuntimeException('A fresh connection is needed!');
        }
        
    }
    
    protected function getResponseLines () {
        $data = '';
        while (!$this->connection->isEOF()) {
            $line = $this->connection->readLine();
            if ($line === false) {
                return false;
            }
            
            $data .= $line;
            $this->callDebugger("< $line");
            if (strlen($line) > 3 && $line[3] === ' ') {
                break;
            }
        }
        
        $this->responseLines .= $data;
        return $data;
    }
    
    protected function writeLine ($line = '') {
        $line = str_replace(array("\r", "\n"), '', "$line");
        $this->commandLines .= "$line\r\n";
        $this->callDebugger("> $line\r\n");
        return $this->connection->write("$line\r\n");
    }
    
    public function readGreetings () {
        if ($this->stage !== self::STAGE_BEFORE_HELLO) {
            return false;
        }
        
        $greetings = $this->getResponseLines();
        if (trim($greetings)[0] !== '2') {
            // rejected
            return false;
        }
        
        return $greetings;
    }
    
    public function sayHello ($hostname = 'localhost') {
        if ($this->stage !== self::STAGE_BEFORE_HELLO) {
            return false;
        }
        
        if (!preg_match('~^\[?[-_./:a-zA-Z0-9]+\]?$~', $hostname)) {
            $hostname = 'localhost';
        }
        
        // Try EHLO first
        if (!$this->writeLine("EHLO $hostname")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            // rejected, try HELO
            if (!$this->writeLine("HELO $hostname")) {
                return false;
            }
            
            $response = $this->getResponseLines();
            if (trim($response)[0] !== '2') {
                // rejected
                return false;
            }
            
        } else {
            $this->ESMTP = true;
            $lines = explode("\n", trim($response));
            foreach ($lines as $line) {
                $line = trim($line);
                $code = substr($line, 0, 3);
                $text = substr($line, 4);
                $divided = explode(' ', $text);
                $keyword = strtoupper(array_shift($divided));
                //$this->callDebugger("text: $text\r\n");
                switch ($keyword) {
                    case 'SIZE':
                        if (
                            isset($divided[0])
                            && is_numeric($divided[0])
                            && $divided[0] > 0
                        ) {
                            $this->maxSize = (int) $divided[0];
                        }
                        break;
                    
                    case 'STARTTLS':
                        $this->STARTTLS = true;
                        break;
                    
                    case 'DSN':
                        $this->dsn = true;
                        break;
                    
                    case 'AUTH':
                        foreach ($divided as $option) {
                            switch ($option) {
                                case 'LOGIN':
                                    $this->login = true;
                                    break;
                                
                                case 'PLAIN':
                                    $this->plain = true;
                                    break;
                                
                                case 'DIGEST-MD5':
                                    $this->digestMD5 = true;
                                    break;
                                
                                case 'CRAM-MD5':
                                    $this->cramMD5 = true;
                                    break;
                                
                            }
                        }
                        break;
                    
                }
            }
        }
        
        $this->stage = self::STAGE_BEFORE_MAIL;
        return true;
    }
    
    public function getMaxSize () {
        return $this->maxSize;
    }
    
    public function isStartTLSSupported () {
        return $this->STARTTLS;
    }
    
    public function startTLS () {
        if ($this->connection->isTLS()) {
            // TLS enabled
            return false;
        }
        
        if ($this->stage !== self::STAGE_BEFORE_MAIL) {
            return false;
        }
        
        if (!$this->STARTTLS) {
            return false; // not supported
        }
        
        if (!$this->writeLine("STARTTLS")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        if (!$this->connection->startTLS()) {
            return false;
        }
        
        $this->stage = self::STAGE_BEFORE_HELLO;
        
        $this->STARTTLS = false;
        
        $this->login = false;
        $this->plain = false;
        $this->digestMD5 = false;
        $this->cramMD5 = false;
        
        $this->dsn = false;
        
        return $this->sayHello();
    }
    
    public function authenticate ($user, $password) {
        if ($this->stage !== self::STAGE_BEFORE_MAIL) {
            return false;
        }
        
        // CRAM_MD5 first? <- not UTF-8 capable
        
        if ($this->cramMD5) {
            // CRAM-MD5
            
            $this->writeLine("AUTH CRAM-MD5");
            $response = $this->getResponseLines();
            if ('334' !== substr($response, 0, 3)) {
                return false;
            }
            
            $challenge = base64_decode(substr(trim($response), 4));
            if (!$challenge) {
                return false;
            }
            
            // in lowercase hex
            $digest = hash_hmac('md5', $challenge, $password);
            $this->writeLine(base64_encode("$user $digest"));
        } elseif ($this->plain) {
            // PLAIN
            $base64 = base64_encode("\0{$user}\0{$password}");
            $this->writeLine("AUTH PLAIN $base64");
        } else {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        return true;
    }
    
    public function mailFrom (EmailAddress $from) {
        if ($this->stage !== self::STAGE_BEFORE_MAIL) {
            return false;
        }
        
        $address = $from->getAddress();
        if (!$this->writeLine("MAIL FROM:<$address>")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        $this->stage = self::STAGE_MAIL_TRANSACTION;
        return true;
    }
    
    public function addRecipient (EmailAddress $to) {
        if ($this->stage !== self::STAGE_MAIL_TRANSACTION) {
            return false;
        }
        
        $address = $to->getAddress();
        if (!$this->writeLine("RCPT TO:<$address>")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        return true;
    }
    
    public function sendData (NewMessage $message) {
        if ($this->maxSize < $message->getSize()) {
            // the message is too large
            return false;
        }
        
        if (!$this->writeLine("DATA")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (substr(trim($response), 0, 3) !== '354') {
            return false;
        }
        
        $lines = explode("\r\n", "$message");
        foreach ($lines as $line) {
            if (strlen($line) > 0 && $line[0] == '.') {
                $line = ".$line";
            }
            
            $line = str_replace(array("\r", "\n"), '', $line);
            
            $this->writeLine($line); // <CR><LF> is appended automatically
        }
        
        $this->writeLine('.');
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        $this->stage = self::STAGE_AFTER_DATA;
        return true;
    }
    
    public function reset () {
        if (!$this->writeLine("RSET")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        return true;
    }
    
    public function noop () {
        if (!$this->writeLine("NOOP")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        return true;
    }
    
    public function quit () {
        if (!$this->writeLine("QUIT")) {
            return false;
        }
        
        $response = $this->getResponseLines();
        if (trim($response)[0] !== '2') {
            return false;
        }
        
        return true;
    }
    
    public function isConnected () {
        return $this->connection->isConnected();
    }
    
    public function isTLS () {
        return $this->connection->isTLS();
    }
    
    public function addDebugListener (/*callable*/ $callback) {
        if (!is_callable($callback)) {
            return false;
        }
        
        if (!in_array($callback, $this->debugHandlers, true)) {
            $this->debugHandlers[] = $callback;
        }
        
        return true;
    }
    
    public function removeDebugListener (/*callable*/ $callback) {
        foreach (array_keys($this->debugHandlers, $callback, true) as $key) {
            unset($this->debugHandlers[$key]);
        }
        
        $this->debugHandlers = array_values($this->debugHandlers);
        
        return true;
    }
    
    protected function callDebugger ($data = '') {
        $count = 0;
        foreach ($this->debugHandlers as $callback) {
            if (!is_callable($callback)) continue;
            $callback("$data");
            $count++;
        }
        
        return $count;
    }
}


// vim: ts=4 et ai

